ageLANServer: Unbounded JSON Array Allocation in AoE3 Cloud `getFileURL` Endpoint Leads to Remote Denial of Service
漏洞描述
### Summary The AoE3 `POST /game/cloud/getFileURL` handler in `luskaner/ageLANServer`'s bundled game server decodes an attacker-controlled `names` JSON array and immediately allocates response storage sized directly from the array's length (`make(i.A, len(req.Names.Data))`), with no request body size limit and no cap on the number of array elements anywhere in the request path. Because the default configuration ships with `Authentication = 'disabled'`, any network client can obtain a session through unauthenticated platform login and then send a single crafted request that forces the server to allocate memory proportional to attacker-chosen input. Dynamic testing against an unmodified build confirmed both large memory-amplification growth from a single request and a full server process kill via the kernel OOM killer when a memory-constrained instance received a few concurrent oversized requests. This is a high-severity, pre-authentication (self-registration only) remote denial-of-service vulnerability (CVSS 3.1 Base Score 7.5, CWE-400). ### Details `server/internal/routes/game/cloud/getFileURL.go` defines the request struct and handler for the AoE3 cloud "get file URL" endpoint: ```go type getFileURLRequest struct { Names i.Json[[]string] `json:"names"` } func GetFileURL(w http.ResponseWriter, r *http.Request) { var req getFileURLRequest err := i.Bind(r, &req) ... descriptions := make(i.A, len(req.Names.Data)) for j, name := range req.Names.Data { ... ``` `Names` is typed as `i.Json[[]string]`, a wrapper whose `UnmarshalText` is invoked by `encoding/json` whenever the incoming JSON value for `names` is itself a string, so the attacker supplies a JSON-string-encoded array (e.g. `{"names":"[\"x\",\"x\",...]"}`). The request body is decoded in `server/internal/http.go`'s `Bind()` via `json.NewDecoder(r.Body).Decode(data)`, with no `http.MaxBytesReader`, no `LimitReader`, and no `MaxHeaderBytes` configured on the `http.Server` in `server/internal/cmd/root.go`. There is also no branch-neutral cap on `len(req.Names.Data)` — the same unbounded length is also used at `getFileURL.go:27` (`slices.Repeat(i.A{nil}, len(req.Names.Data))` when the cloud file cache is empty) in addition to the `make(i.A, len(req.Names.Data))` sink at line 30. Because `len()` on the attacker-supplied slice is used directly as an allocation size with no upper bound, an attacker can force the server to allocate memory (and CPU cycles decoding/iterating) proportional to the size of a single HTTP request body, which is itself unbounded. Reaching this code path requires only a valid session, not real game ownership or credentials: - The endpoint is registered for the AoE3 title at `server/internal/routes/router/game.go:267`. - `server/internal/routes/router/sessionMiddleware.go:23-36` only checks that `sessionID` resolves to an existing session — it performs no additional authorization. - The default configuration `server/resources/config/config.toml` sets `Authentication = 'disabled'`. Combined with `server/internal/routes/game/login/platformlogin.go`, which issues a session for any client-supplied `accountType`/`platformUserID` without verifying it against a real platform, an attacker can obtain a valid `sessionID` with a single unauthenticated `platformlogin` request and no prior credentials. Data flow (source → sink): 1. `server/resources/unix/start_age3.sh:4` (or `start_age3.bat`) starts the release server with `-e age3`, the default supported way to run the AoE3 server. 2. `server/internal/routes/router/game.go:267` registers `POST /game/cloud/getFileURL`. 3. `server/internal/routes/router/sessionMiddleware.go:23-36` accepts any request bearing a valid `sessionID`. 4. `server/internal/http.go` decodes the JSON body into `getFileURLRequest` with no size limiting. 5. `server/internal/routes/game/cloud/getFileURL.go:13-19` binds the attacker-controlled `names` array into `req.Names`. 6. `server/internal/routes/game/cloud/getFileURL.go:27` and `:30` allocate slices sized directly by `len(req.Names.Data)`. ### PoC Environment: Docker (see `Dockerfile`), which builds the **unmodified** `server` and `genCert` binaries directly from the repository source at commit `405b9a9` (equivalent to the previously reported `870b90c`) and starts the AoE3 server (`./server -e age3`) with the shipped default configuration (`Authentication = 'disabled'`), only disabling LAN announcement broadcast since it is irrelevant to the sandboxed reproduction. Build and run: ```bash docker build -f vuln-001/Dockerfile -t agelan-vuln001 repo docker run -d --name agelan-vuln001-test -p 8443:443 --memory=300m agelan-vuln001 ``` Attack script: `poc.py` (see file for full source). At a high level it: 1. Calls `POST /game/login/platformlogin` with an arbitrary Steam-style `accountType`/`platformUserID`/`macAddress` to obtain a session id, exploiting the default `Authentication = 'disabled'` setting. 2. Sends a small baseline `POST /game/cloud/getFileURL?sessionID=<id>` request (`names` array with 5 elements) and records server RSS before/after via `docker exec ... cat /proc/1/status`. 3. Sends a single request with `names` containing 2,000,000 elements and records the resulting RSS growth relative to request size (memory amplification). 4. Against the same container restarted with `--memory=300m`, fires 3 concurrent requests each with `names` containing 3,000,000 elements, then inspects `docker inspect` state to check for an OOM kill. ```bash python3 poc.py --host 127.0.0.1 --port 8443 --container agelan-vuln001-test ``` Observed results (Phase 2 dynamic reproduction, unmodified source): - Baseline request (`names` count = 5, 43 request bytes): server RSS unchanged (16928 KB → 16928 KB). - Single moderately large request (`names` count = 2,000,000, 12,000,013 request bytes): server RSS jumped from 16,984 KB to 224,724 KB — a growth of 207,740 KB from a ~11.4 MB request (~17.73x amplification of request size in RSS growth). - Concurrent attack phase against the same container limited to `--memory=300m`: 3 concurrent requests of `names` count = 3,000,000 each (18,000,013 bytes each) were sent; all 3 connections failed with `RemoteDisconnected: Remote end closed connection without response`. `docker inspect` confirmed the container transitioned from `status=running, OOMKilled=false` to `status=exited, OOMKilled=true, ExitCode=137` — the kernel OOM killer terminated the server process. No source code in the target repository was modified for this reproduction; the container builds and runs the vulnerable code exactly as shipped. ### Impact This is a remote, network-reachable, pre-authentication (self-registration-only) denial-of-service vulnerability (CWE-400: Uncontrolled Resource Consumption). Any client able to reach the AoE3 game server's HTTPS listener — the default and documented way to run `ageLANServer` for Age of Empires III — can obtain a session via the default unauthenticated login flow and then crash or hang the server process with a single well-crafted HTTP request or a small number of concurrent requests, without needing legitimate game credentials, prior interaction, or user interaction. Impact is availability-only (no confidentiality or integrity impact observed): all players connected to the affected LAN server lose service until the operator restarts the process. ### Reproduction artifacts #### `Dockerfile` ```dockerfile # syntax=docker/dockerfile:1 # # VULN-001 PoC image for luskaner/ageLANServer. # Builds the *unmodified* server and genCert binaries straight from the cloned # repository source (no source-code edits) and runs the AoE3 game server so the # unbounded-allocation "names" array bug in # server/internal/routes/game/cloud/getFileURL.go # can be triggered over the network exactly as an attacker would. # # Build context MUST be the ageLANServer repository root, e.g.: # docker build -f vuln-001/Dockerfile -t agelan-vuln001 <path-to-repo> FROM golang:1.26-alpine3.24 AS